RSpecのテストは、モデルやリクエストのspecが増えるほど実行に時間がかかるようになります。数行の修正でも全specを実行する構成のままだと、プルリクエストを出すたびにCIの結果を長く待つことになります。
CircleCIのSmarter Testingを使うと、変更したファイルに関係するspecだけを選んで実行できます。この選択を担うのがTest Impact Analysisです。RSpecでは公式プラグインのrspec-circleci-coverageが、どのspecがどのファイルを実行したかを記録します。
この記事では、小さなRSpecプロジェクトを作りながら、Test Impact Analysisを手元で動かすまでの手順を確認します。あわせて、shared examplesやYAMLから生成するspecのように、カバレッジだけでは変更との関係を追えないspecがどう扱われるかと、その塞ぎ方も手元で再現します。
この記事でわかること
-
rspec-circleci-coverageと.circleci/test-suites.ymlの設定を、doctorで確かめながら仕上げる手順 - ソースファイルを変えたときに、関係するspecだけが選ばれる様子
- shared examplesとYAMLを使うspecが選ばれないケースと、
test-selection-rulesでの塞ぎ方 - CircleCIのジョブへの組み込み方
検証環境
| 対象 | バージョン |
|---|---|
| Ruby | 4.0.6 |
| circleci CLI | 1.0.51648(testsuiteサブコマンド 1.0.61831) |
| rspec-circleci-coverage | 0.1.659 |
Test Impact AnalysisはSmarter Testingの有料機能(opt-in)です。料金はCircleCIの料金ページを確認してください。この記事のローカルでの確認(--local)では、impact dataは手元のファイルに保存されます。
サンプルプロジェクトを用意する
税込価格と割引率を計算する3つのクラスと、それを確かめる4つのspecを作ります。specの書き方をわざと変えてあり、あとの手順で「選ばれるspec」と「選ばれないspec」を見比べられます。
-
price_spec.rb:Priceを直接呼ぶ、ふつうのspec -
price_loop_spec.rb: 配列のループでexampleを生成するspec -
discount_data_spec.rb: YAMLファイルの行ごとにexampleを生成するspec -
price_shared_spec.rb: ローカルのitと、spec/support/のshared examplesを併用するspec
mkdir ruby-smarter-testing && cd ruby-smarter-testing
Gemfileには、RSpecとJUnit形式の結果を出力するフォーマッターを入れます。Smarter Testingはrunの結果をJUnit XMLで受け取るため、rspec_junit_formatterが必要です。
source "https://rubygems.org"
gem "rspec"
gem "rspec_junit_formatter"
依存を入れてから、RSpecの初期ファイルを作ります。bundle installのあとにrspec --initです。
$ bundle install
Fetching gem metadata from https://rubygems.org/...
Resolving dependencies...
Bundle complete! 2 Gemfile dependencies, 7 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
$ bundle exec rspec --init
create .rspec
create spec/spec_helper.rb
.rspecには--require spec_helperが入るので、各specでspec_helperをrequireする必要はありません。spec/spec_helper.rbは、rspec --initが生成したコメント付きの設定一式のままです。この記事では、そのファイルを丸ごと差し替えず、必要な行だけ足していきます。
続けてディレクトリとソースを作ります。
mkdir -p lib spec/support spec/fixtures .circleci
ソースファイルを3つ作ります。Roundingは、shared examplesの中からしか呼ばれないクラスです。
class Price
def self.with_tax(amount) = (amount * 1.1).round
end
Discountは会員ランク文字列から割引率を返します。
class Discount
RATES = { "gold" => 0.2, "silver" => 0.1, "none" => 0.0 }
def self.rate(rank)
RATES.fetch(rank)
end
end
Roundingは金額の端数処理だけを担当します。
class Rounding
def self.apply(amount)
amount.round
end
end
Discount.rateは複数行のdefで書いてください。手元の検証では、def self.rate(rank) = RATES.fetch(rank)のように1行のendless methodで定数Hashのfetchを呼ぶと、Rubyのカバレッジに呼び出しが記録されませんでした。このときdiscount_data_spec.rbとlib/discount.rbの関係はimpact dataに残りません。
生成されたspec_helper.rbに、spec/support/配下を読み込む行を足します。
# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
+Dir[File.join(__dir__, "support", "**", "*.rb")].sort.each { |f| require f }
+
RSpec.configure do |config|
あとの手順で選ばれる/選ばれない差を見るため、書き方の違う4つのspecと、それらが読むfixture・shared examplesを用意します。
まずPriceを直接呼ぶ、ふつうのspecです。
require_relative "../lib/price"
RSpec.describe(Price) { it("100円は税込110円") { expect(Price.with_tax(100)).to eq(110) } }
次は配列のループでexampleを生成するspecです。カバレッジ上は、ふつうのspecと同じくlib/price.rbとの関係が残ります。
require_relative "../lib/price"
RSpec.describe "ループで生成" do
[[200, 220], [300, 330]].each do |amount, expected|
it("#{amount}円は税込#{expected}円") { expect(Price.with_tax(amount)).to eq(expected) }
end
end
YAMLの行ごとにexampleを作るspecと、その入力です。あとでYAMLだけを変えたときに、選ばれない側になります。
require_relative "../lib/discount"
require "yaml"
RSpec.describe "YAMLから生成" do
YAML.load_file(File.join(__dir__, "fixtures", "discount_cases.yml")).each do |c|
it("#{c["rank"]}の割引率は#{c["rate"]}") { expect(Discount.rate(c["rank"])).to eq(c["rate"]) }
end
end
discount_data_spec.rbが読むfixtureはrankとrateの行です。
- { rank: gold, rate: 0.2 }
- { rank: silver, rate: 0.1 }
最後に、ローカルのitとspec/support/のshared examplesを併用するspecです。shared側だけがRoundingを呼びます。
require_relative "../lib/price"
RSpec.describe("shared_examplesで生成") do
it("ローカル固定5000円は5500円") { expect(Price.with_tax(5000)).to eq(5500) }
it_behaves_like "税込価格", 1000, 1100
end
shared examplesの定義はspec/support/に書きます。
require_relative "../../lib/rounding"
RSpec.shared_examples "税込価格" do |amount, expected|
it("#{amount}円は税込#{expected}円") { expect(Rounding.apply(Price.with_tax(amount))).to eq(expected) }
end
全specが通ることを確認します。次の出力なら準備は完了です。
$ bundle exec rspec
.......
Finished in 0.00222 seconds (files took 0.0638 seconds to load)
7 examples, 0 failures
最後にGitリポジトリとして初期化し、コミットしておきます。あとの差分確認のためです。
git init
git add -A && git commit -m "initial"
プラグインを入れ、カバレッジを確かめる
rspec-circleci-coverageを入れます。bundle addがGemfileへの追加とインストールをまとめてやってくれます。
$ bundle add rspec-circleci-coverage
Fetching gem metadata from https://rubygems.org/...
Resolving dependencies...
Fetching gem metadata from https://rubygems.org/...
Resolving dependencies...
Fetching rspec-circleci-coverage 0.1.659
Installing rspec-circleci-coverage 0.1.659
Gemfileには次の行が足されます。バージョン制約はインストール時点のものです。
gem "rspec-circleci-coverage", "~> 0.1.659"
先ほど足したsupportの読み込みより前で、プラグインを読み込みます。pluginsブロックはrequire行の直前に挿入します。
+require "rspec-circleci-coverage"
+
Dir[File.join(__dir__, "support", "**", "*.rb")].sort.each { |f| require f }
RSpec.configure do |config|
プラグインがカバレッジを記録するのは、環境変数CIRCLECI_COVERAGEが設定されたときだけです。普段のbundle exec rspecの動きは変わりません。公式READMEと同じ手順で、一度カバレッジを出してみましょう。
$ CIRCLECI_COVERAGE=coverage.json bundle exec rspec
rspec-circleci-coverage: generating CircleCI coverage JSON...
.......rspec-circleci-coverage: wrote coverage.json
Finished in 0.01288 seconds (files took 0.09192 seconds to load)
7 examples, 0 failures
$ cat coverage.json | jq .
{
"lib/discount.rb": {
"spec/discount_data_spec.rb!!YAMLから生成 goldの割引率は0.2|run": true,
"spec/discount_data_spec.rb!!YAMLから生成 silverの割引率は0.1|run": true
},
"lib/price.rb": {
"spec/price_loop_spec.rb!!ループで生成 200円は税込220円|run": true,
"spec/price_loop_spec.rb!!ループで生成 300円は税込330円|run": true,
"spec/price_shared_spec.rb!!shared_examplesで生成 ローカル固定5000円は5500円|run": true,
"spec/support/shared_price_examples.rb!!shared_examplesで生成 behaves like 税込価格 1000円は税込1100円|run": true,
"spec/price_spec.rb!!Price 100円は税込110円|run": true
},
"spec/support/shared_price_examples.rb": {
"spec/support/shared_price_examples.rb!!shared_examplesで生成 behaves like 税込価格 1000円は税込1100円|run": true
},
"lib/rounding.rb": {
"spec/support/shared_price_examples.rb!!shared_examplesで生成 behaves like 税込価格 1000円は税込1100円|run": true
}
}
lib/price.rbは各specに紐付いています。一方lib/rounding.rbの紐付け先はspec/support/shared_price_examples.rbです。discoverが拾うatomは*_spec.rbだけなので、この紐付けは後述のimpact dataには残りません。このcoverage.jsonは確認用なので、.gitignoreに入れておきます。
echo coverage.json >> .gitignore
test-suites.ymlを書き、doctorで確かめる
.circleci/test-suites.ymlを作ります。
---
name: rspec tests
discover: find spec -type f -name '*_spec.rb'
run: bundle exec rspec --format RspecJunitFormatter --out "<< outputs.junit >>" << test.atoms >>
analysis: CIRCLECI_COVERAGE="<< outputs.circleci-coverage >>" bundle exec rspec << test.atoms >>
outputs:
junit: test-reports/junit.xml
options:
test-impact-analysis: true
discoverはspecファイルを列挙し、runは渡されたspecを実行してJUnit XMLを書き出します。この2つの組み立て方は、test-suites.ymlでテスト分割を始める記事で紹介しています。
Test Impact Analysisのために足すのがanalysisです。runとほぼ同じコマンドですが、CIRCLECI_COVERAGEに<< outputs.circleci-coverage >>を渡す点だけが異なります。プラグインがこの出力先に書いたカバレッジから、CircleCIはspecとファイルの対応関係(impact data)を作ります。optionsのtest-impact-analysis: trueで機能を有効にしてください。
設定を書いたらdoctorを実行します。
$ circleci testsuite doctor "rspec tests" --json
checking test-suite configuration exists...
checking detect test runner...
checking test-suite configuration is valid...
checking discover command can discover test atoms...
checking run command can run discovered test atoms...
checking file-mapper command can map test atoms to files...
checking analysis command can analyze test atoms...
{
"checks": [
...
{
"name": "discover command can discover test atoms",
"output": "Discovered 4 test atoms",
"state": "success"
},
{
"name": "run command can run discovered test atoms",
"output": "Ran 4 test atoms",
"state": "success"
},
{
"name": "file-mapper command can map test atoms to files",
"output": "check skipped: all test atoms are files, file-mapper is not needed",
"state": "skipped"
},
{
"name": "analysis command can analyze test atoms",
"output": "4 test atoms are impacted by source files",
"state": "success"
}
],
"action_items": [],
最後のanalysisのチェックがsuccessで、action_itemsが空になっていれば設定は完成です。analysisを書き忘れた場合は、The 'analysis' command is missing when 'test-impact-analysis' is enabled.というaction itemが出て、RSpec用のプラグインの入れ方が案内されます。
impact dataを作り、関係するspecだけが選ばれることを確かめる
ここまでの変更をコミットしてから、--analyze-tests=impactedを付けてtestsuiteを実行します。
git add -A && git commit -m "Configure test impact analysis"
circleci testsuite run "rspec tests" --analyze-tests=impacted --local
--localを付けると、impact dataはCircleCIへ送らず、手元の.circleci/impact-default.jsonへの保存になります。付けずに実行すると、CircleCIのプロジェクトIDと認証を求められて止まります。
Missing required flags when '--local' is not set: --project-id=UUID
初回はimpact dataが無いので全specが選ばれる状態です。そのあとでanalysisが走ります。
Selecting tests...
Selecting all test atoms, no impact analysis available
Selected 4 test atoms, Skipped 0 test atoms in 0s
Running 4 test atoms
...
Analyzing 4 test atoms
...
Found 1 files impacting test spec/price_loop_spec.rb
Found 1 files impacting test spec/discount_data_spec.rb
Found 1 files impacting test spec/price_spec.rb
Found 1 files impacting test spec/price_shared_spec.rb
Updated test impact data in 0s
Found 1 filesは、spec自身を除いて、そのspecの実行したソースファイルが1つ記録されたという意味です。price_spec.rb、price_loop_spec.rb、price_shared_spec.rbはlib/price.rbに、discount_data_spec.rbはlib/discount.rbに紐付きます。
--localでは、この対応関係が.circleci/impact-default.jsonに保存されます(確認用のファイルなので、コミットしないでください)。中身はだいたい次の形です。filesがパスとハッシュの一覧、edgesがdiscoverで得たatom(specファイル)から、そのatomが実行したファイルへの対応です。数値はfilesのIDを指します。
{
"files": {
"1": { "path": "lib/price.rb", "hash": "..." },
"3": { "path": "lib/discount.rb", "hash": "..." },
"5": { "path": "spec/price_spec.rb", "hash": "..." }
},
"edges": {
"spec/price_spec.rb": [1, 5],
"spec/price_loop_spec.rb": [2, 1],
"spec/price_shared_spec.rb": [1, 8],
"spec/discount_data_spec.rb": [3, 4]
}
}
price_spec.rbの[1, 5]はlib/price.rbと自分自身、discount_data_spec.rbの[3, 4]はlib/discount.rbと自分自身です。一方、このグラフにlib/rounding.rbとspec/fixtures/discount_cases.ymlは出てきません。先ほどのcoverage.jsonではlib/rounding.rbがspec/support/shared_price_examples.rbに紐付いていましたが、discoverのatomは*_spec.rbだけなので、その関係はimpact dataに残りません。この差が、次の節で「選ばれない」動きにつながります。
変更が無い状態で、選ばれるspecの一覧を見てみます。list-testsはspecを実行せず、選ばれる一覧だけを表示します。何も出ません。
$ circleci testsuite list-tests "rspec tests" --local
では、lib/price.rbを変えてみましょう。計算結果を変えない次の変更をします。
class Price
- def self.with_tax(amount) = (amount * 1.1).round
+ def self.with_tax(amount) = (amount * 1.1).round + 0
end
同じコマンドをもう一度実行します。今度は選ばれる一覧が出ます。
$ circleci testsuite list-tests "rspec tests" --local
spec/price_loop_spec.rb
spec/price_spec.rb
spec/price_shared_spec.rb
Priceを呼んでいる3つのspecが選ばれ、Priceを使わないdiscount_data_spec.rbは外れました。runで実行すると、選択の内訳と、実際に渡されたコマンドが見えます。
$ circleci testsuite run "rspec tests" --local
Selecting tests...
Found test impact version: 1
Using 'impact-key' 'default'
- 0 new test atoms
- 0 test atoms impacted by new files
- 3 test atoms impacted by modified files
- 0 test atoms impacted by removed files
- 0 test atoms failed previously
- 0 test atoms with no source file mappings in impact data
- 0 test atoms impacted by include rule
- 0 test atoms impacted by full test run paths
Selected 3 test atoms, Skipped 1 test atoms in 1ms
Running 3 test atoms
[2] bundle exec rspec --format RspecJunitFormatter --out "test-reports/junit-1.xml" spec/price_loop_spec.rb spec/price_spec.rb spec/price_shared_spec.rb
Ran 3 test atoms in 241ms
Analysis not configured
Not updating test impact data, analysis not enabled
内訳のimpacted by modified filesが、変更したソースに紐付く選択です。Runningの行では、test-suites.ymlのrunにある<< test.atoms >>が、選ばれた3つのspecパスに置き換わっています。ループでexampleを生成するprice_loop_spec.rbも、ふつうのspecと同じく選ばれています。確認が終わったら変更を戻してください。
git checkout -- lib/price.rb
CIでも同じ仕組みです。impact dataがCircleCI側にあれば、フィーチャーブランチでは変更に関係するspecだけが選ばれます。一方、デフォルトブランチでは既定で全specが実行され、あわせてimpact dataが更新されます。
shared examplesとYAMLを使うspecは選ばれない
次は、shared examplesの中でしか使わないlib/rounding.rbを変えます。
class Rounding
def self.apply(amount)
- amount.round
+ (amount + 0).round
end
end
Roundingだけを変えた場合、testsuiteは4 atomすべてをスキップします。
$ circleci testsuite list-tests "rspec tests" --local
$ circleci testsuite run "rspec tests" --local
...
- 0 test atoms impacted by modified files
...
Selected 0 test atoms, Skipped 4 test atoms in 0s
list-testsは何も表示せず、runも4つのspecをすべてスキップしました。price_shared_spec.rbはit_behaves_likeでRoundingを実行しているのに、選ばれていません。
先ほどのcoverage.jsonでは、lib/rounding.rbの紐付け先がspec/support/shared_price_examples.rb側のatomだけになっています。
"lib/rounding.rb": {
"spec/support/shared_price_examples.rb!!shared_examplesで生成 behaves like 税込価格 1000円は税込1100円|run": true
}
discoverが拾うatomは*_spec.rbだけなので、この関係はimpact dataに入りません。次の節で宣言して塞ぎます。
変更を戻して、今度はYAMLに誤った行を1つ足します。noneの割引率は0.0なので、この行のexampleは失敗するはずです。
git checkout -- lib/rounding.rb
続けてfixtureに誤った行を足します。
- { rank: gold, rate: 0.2 }
- { rank: silver, rate: 0.1 }
+- { rank: none, rate: 0.5 }
fixtureに誤った行を足しただけでは、YAMLはカバレッジに載らずatomは選ばれません。
$ circleci testsuite run "rspec tests" --local
...
Selected 0 test atoms, Skipped 4 test atoms in 0s
discount_data_spec.rbは選ばれず、失敗は見つかりません。Rubyのカバレッジは実行されたRubyコードの行を記録するもので、読み込んだYAMLの中身は対象外だからです。
--run-tests=allで全specを実行すると、この誤りが見つかります。
$ circleci testsuite run "rspec tests" --local --run-tests=all
...
Running all test atoms, --run-tests set to 'all'
...
### Failed test atoms (1)
**spec/discount_data_spec.rb**
- spec.discount_data_spec > YAMLから生成 noneの割引率は0.5
CIに置き換えると、フィーチャーブランチのテストは成功し、誤りが見つかるのはマージ後にデフォルトブランチで全specが実行されたときです。YAMLを元に戻しておきます。
git checkout -- spec/fixtures/discount_cases.yml
test-selection-rulesで依存を宣言する
カバレッジで追えない関係は、optionsのtest-selection-rulesで宣言します。test-atomに書いたspecが、includeに書いたファイルの変更でも選ばれるようになります。
まず、宣言候補のspecをgrepで洗い出します。ヒットするのはshared examplesとYAML読み込みの2本だけです。
$ grep -rlE "it_behaves_like|include_examples|it_should_behave_like" spec --include='*_spec.rb'
spec/price_shared_spec.rb
$ grep -rlE "YAML\.(load_file|safe_load_file)|File\.read|JSON\.parse|CSV\.(read|foreach)" spec --include='*_spec.rb'
spec/discount_data_spec.rb
ヒットしたspecごとに、includeに書くパスを中身から特定します。shared examples側は、呼んでいる名前から定義ファイルを探し、そこがrequireしているソースも拾います。
$ grep -nE "it_behaves_like|include_examples|it_should_behave_like" spec/price_shared_spec.rb
5: it_behaves_like "税込価格", 1000, 1100
$ grep -rn 'shared_examples "税込価格"' spec
spec/support/shared_price_examples.rb:3:RSpec.shared_examples "税込価格" do |amount, expected|
$ grep -nE "require|require_relative" spec/support/shared_price_examples.rb
1:require_relative "../../lib/rounding"
データファイル側は、読み込み行の引数を実パスに直してください。load_fileの第1引数がfixtureパスです。
$ grep -nE "YAML\.(load_file|safe_load_file)|File\.read|JSON\.parse|CSV\.(read|foreach)" spec/discount_data_spec.rb
5: YAML.load_file(File.join(__dir__, "fixtures", "discount_cases.yml")).each do |c|
__dir__はspec/なので、実パスはspec/fixtures/discount_cases.ymlです。ここまでで揃ったパスをtest-selection-rulesに書きます。
options:
test-impact-analysis: true
+ test-selection-rules:
+ - test-atom: spec/discount_data_spec.rb
+ include:
+ - spec/fixtures/*.yml
+ - test-atom: spec/price_shared_spec.rb
+ include:
+ - spec/support/*.rb
+ - lib/rounding.rb
price_shared_spec.rbには、shared examplesの定義ファイルに加えてlib/rounding.rbも並べています。includeに書いたファイルの変更だけが選ばれるため、spec/support/*.rbだけではlib/rounding.rbの変更では選ばれません。
test-suites.ymlを変えたら、impact dataを作り直してください。test-suites.yml自体の変更は全specを実行する扱いになるため、作り直さないと次の実行でも全specが選ばれます。
$ circleci testsuite run "rspec tests" --analyze-tests=impacted --local
...
Selecting all test atoms, 'full-test-run-paths' detected modified file: .circleci/test-suites.yml
...
宣言が効くかを確かめるため、先ほど選ばれなかった2つの変更を同時に入れます。今度は結果が壊れる内容です。
class Rounding
def self.apply(amount)
- amount.round
+ (amount + 1).round
end
end
あわせてfixtureに誤った行を1行足します。
- { rank: gold, rate: 0.2 }
- { rank: silver, rate: 0.1 }
+- { rank: none, rate: 0.5 }
includeルールを足したうえで同じ変更を入れると、impacted by include ruleで2 atomが選ばれます。
$ circleci testsuite run "rspec tests" --local
Selecting tests...
Found test impact version: 1
Using 'impact-key' 'default'
- 0 new test atoms
- 0 test atoms impacted by new files
- 0 test atoms impacted by modified files
- 0 test atoms impacted by removed files
- 0 test atoms failed previously
- 0 test atoms with no source file mappings in impact data
- 2 test atoms impacted by include rule
- 0 test atoms impacted by full test run paths
Selected 2 test atoms, Skipped 2 test atoms in 2ms
Running 2 test atoms
[2] bundle exec rspec --format RspecJunitFormatter --out "test-reports/junit-1.xml" spec/discount_data_spec.rb spec/price_shared_spec.rb
Ran 2 test atoms in 284ms
Analysis not configured
Not updating test impact data, analysis not enabled
'run' [2] CircleCI received a non-zero exit code from the test runner: exit status 1
### Failed test atoms (2)
**spec/discount_data_spec.rb**
- spec.discount_data_spec > YAMLから生成 noneの割引率は0.5
**spec/price_shared_spec.rb**
- spec.price_shared_spec > shared_examplesで生成 behaves like 税込価格 1000円は税込1100円
内訳のimpacted by include ruleが2件で、宣言した2つのspecだけが選ばれ、<< test.atoms >>にもその2つが渡りました。どちらも失敗したので、確認が終わったら次のコマンドで戻してください。
git checkout -- lib/rounding.rb spec/fixtures/discount_cases.yml
YAMLを戻したあとの最初の実行では、直前に失敗したspecがfailed previouslyとして数えられ、変更が無くても選ばれます。失敗修復の確認用に用意された挙動です。
includeのglobは、実在するファイルに1件もマッチしないと設定エラーになります。手元の検証では、spec/fixtures/直下にしかYAMLが無い状態でspec/fixtures/**/*.ymlと書いたところ、doctorが次のエラーで止まりました。
unknown failure running check: error in include rule for 'spec/discount_data_spec.rb': no matches for glob 'spec/fixtures/**/*.yml'
**に限らず、マッチが0件になるglobはすべて同じエラーです。実在するパスに合わせて書いてください。
特定のspecに限らず全体の動作に影響するファイルは、full-test-run-pathsに書きます。何も書かない場合は.circleci/test-suites.ymlとGemfile.lockが対象で、どちらかが変わると、選択の内訳にimpacted by full test run pathsと表示されて全specが選ばれます。DBマイグレーションなどを足すときは、明示したリストが既定値を置き換えるため、既定の2つも一緒に書いてください。
full-test-run-paths:
- .circleci/test-suites.yml
- Gemfile.lock
- db/migrate/*.rb
CircleCIで動かす
doctorの案内どおり、CIではテストの実行ステップをcircleci testsuite runに置き換えます。JUnit XMLの取り込みもtestsuiteに任せるため、test-suites.ymlのoptionsにstore-test-results: trueを足し、store_test_resultsステップは使いません。
options:
test-impact-analysis: true
+ store-test-results: true
CIではbundle installのあとcircleci testsuite runだけを実行するジョブに置き換えます。
version: 2.1
jobs:
test:
docker:
- image: cimg/ruby:4.0
steps:
- checkout
- run:
name: Install dependencies
command: bundle install
- run:
name: Run testsuite
command: circleci testsuite run "rspec tests" --analyze-tests=impacted
workflows:
ci-tests:
jobs:
- test
--localを付けないので、impact dataはCircleCI側に保存されます。手元の.circleci/impact-*.jsonは確認用なので、コミットしないでください。あわせて.circleci/state.jsonもローカル実行の状態用なので、.gitignoreに.circleci/impact-*.jsonと.circleci/state.jsonを入れておくと安心です。
デフォルトブランチでは--analyze-testsは既定でimpacted、フィーチャーブランチではnoneです。前の節では手元で--analyze-tests=impactedを付けてimpact dataを作りましたが、上のconfig.ymlのとおりフラグを省略すると、フィーチャーブランチのビルドではanalysisが走りません。デフォルトブランチのビルドがまだ無い導入直後はimpact dataが増えず、先ほどのSelecting all test atoms, no impact analysis availableのままに見えがちです。導入の最初期だけ--analyze-tests=impactedを付けてください。analysisはカバレッジ付きの実行になる分、初回は特にジョブが長くなります。デフォルトブランチでimpact dataが更新されるようになったら、フラグを外して既定に戻します。
最初のフィーチャーブランチより前にimpact dataを用意したい場合は、手元からCircleCIへ送る実行です。--localを外すと--project-idが必須です。circleci auth loginで認証してから、次のように実行します。
circleci auth login
export PROJECT_ID=$(circleci project get --json | jq -r .id)
circleci testsuite run "rspec tests" --analyze-tests=impacted --project-id="$PROJECT_ID"
circleci project getは、作業ディレクトリのgit remoteからプロジェクトを解決します。CircleCI側でプロジェクトをフォロー済みであることが前提です。
まとめ
RSpecでTest Impact Analysisを使うには、rspec-circleci-coverageと.circleci/test-suites.ymlのanalysisを用意し、impact dataを作ります。カバレッジに載る関係なら、ソースを変えたときに関係するspecだけが選ばれます。
一方、shared examplesの定義ファイルやYAMLのようなデータファイルへの依存はカバレッジだけでは残りません。選ばれない変更は、specの読み込み先を辿ってtest-selection-rulesのincludeに書きます。宣言のあとは、list-testsやrunの内訳で「意図したspecだけが選ばれているか」を確認してください。
