This document sheds some light on why RSpec Tracer might be helpful and talks about implementation details of managing dependency, managing flaky tests, skipping tests, and caching on CI.
It's just not about reducing the time taken to run tests but also getting answers to questions that come before thinking about writing a single-line code. At times, we don't know which other components require testing because of the changes introduced. One possible way to have insights on this is to know which spec files would run some or all the tests when changing a specific source or spec file.
Since we know that a particular file is testing a specific component or feature, we get some idea of what else could change and whether it's desirable or not. We can also analyze the dependency amongst these components and retrospect the design choices made with this data. Indeed, this also comes in handy when we are refactoring the code-base. We can pick pieces with the least number of dependencies and avoid any surprises.
{
"app/course.rb": [
"spec/course_spec.rb", "spec/student_spec.rb"
],
"app/student.rb": [
"spec/course_spec.rb", "spec/student_spec.rb"
],
"spec/course_spec.rb": [
"spec/course_spec.rb"
],
"spec/spec_helper.rb": [
"spec/course_spec.rb", "spec/student_spec.rb"
],
"spec/student_spec.rb": [
"spec/student_spec.rb"
]
}
There is an option to run tests parallel and save time, but it runs all the tests. If we know for a fact that in the current state, a given set of tests needs run, why run the entire suite? Earlier, we talked about maintaining a map of files to spec files, but it is not helpful here. What we need is a map of the test to source and spec files. With this, we can check if any of the files changed or not. If not, no need to run this particular test.
{
"RSpec::ExampleGroups::Course::Enrolled::WithStudent#enrolls student": [
"app/course.rb", "app/student.rb", "spec/course_spec.rb", "spec/spec_helper.rb"
],
"RSpec::ExampleGroups::Course::Enrolled::WithoutStudent#does not enroll": [
"app/course.rb", "spec/course_spec.rb", "spec/spec_helper.rb"
],
"RSpec::ExampleGroups::Student::Email::WithEmail#sets email": [
"app/student.rb", "spec/spec_helper.rb", "spec/student_spec.rb"
],
"RSpec::ExampleGroups::Student::Email::WithoutEmail#does not set email": [
"spec/spec_helper.rb", "spec/student_spec.rb"
],
"RSpec::ExampleGroups::Student::Enroll::WithCourse#enroll student": [
"app/course.rb", "app/student.rb", "spec/spec_helper.rb", "spec/student_spec.rb"
],
"RSpec::ExampleGroups::Student::Enroll::WithoutCourse#does not enroll": [
"app/student.rb", "spec/spec_helper.rb", "spec/student_spec.rb"
],
"RSpec::ExampleGroups::Student::Mobile::WithMobile#sets mobile": [
"app/student.rb", "spec/spec_helper.rb", "spec/student_spec.rb"
],
"RSpec::ExampleGroups::Student::Mobile::WithoutMobile#does not set mobile": [
"app/student.rb", "spec/spec_helper.rb", "spec/student_spec.rb"
],
"RSpec::ExampleGroups::Student::Name::WithName#sets name": [
"app/student.rb", "spec/spec_helper.rb", "spec/student_spec.rb"
],
"RSpec::ExampleGroups::Student::Name::WithoutName#does not set name": [
"app/student.rb", "spec/spec_helper.rb", "spec/student_spec.rb"
]
}
Let's see how we can create both these two maps.
We will maintain reference coverage data, and then at the end of each test run, we find all such files which have coverage diff. These are the files on which this particular test depends.
# List of tests we are running
tests = []
# Dependencies map
deps = {}
# Before starting execution of test suite
# Store the reference coverage
ref_cov = Coverage.peek_result
tests.each do |test|
# Run test
test.run
# After test is run
test.after_run do
# Store the current coverage
curr_cov = Coverage.peek_result
# Find all the files with some coverage diff
deps[test] = changed_files(ref_cov, curr_cov)
# Update the reference coverage for next test
ref_cov = curr_cov
end
end
That's all we need to have the tests to files dependencies. Suppose the map looks like:
{
'test_1' => %w[spec_file_1.rb source_file_1.rb source_file_2.rb],
'test_2' => %w[spec_file_1.rb source_file_2.rb],
'test_3' => %w[spec_file_3.rb source_file_4.rb],
'test_4' => %w[spec_file_2.rb source_file_1.rb source_file_3.rb]
}
Looking at the map, we can say:
- We only need to run
test_2
if one or more dependent files (spec_file_1.rb
,source_file_2.rb
) change. - Similarly, run
test_4
if any of thespec_file_2.rb
,source_file_1.rb
, andsource_file_3.rb
change.
Using this map, we can create the source and spec files to dependent spec files map:
# Test to spec file map, i.e., which spec file defines this particular test
tests = {}
# Test to files dependencies map
deps = {}
# File to spec files dependencies map
file_deps = Hash.new { |hash, key| hash[key] = Set.new }
deps.each_pair do |test, files|
files.each do |file|
file_deps[file] << tests[test]
end
end
Assuming:
spec_file_1.rb
definestest_1
andtest_2
.spec_file_2.rb
definestest_4
.spec_file_3.rb
definestest_3
.
We get the following map:
{
'source_file_1.rb' => %w[spec_file_1.rb spec_file_2.rb],
'source_file_2.rb' => %w[spec_file_1.rb],
'source_file_3.rb' => %w[spec_file_2.rb],
'source_file_4.rb' => %w[spec_file_3.rb],
'spec_file_1.rb' => %w[spec_file_1.rb],
'spec_file_2.rb' => %w[spec_file_2.rb],
'spec_file_3.rb' => %w[spec_file_3.rb]
}
As we have the test dependency map, we can use it to skip tests with no dependency changes.
tests.each do |test|
test.before_run do
# Skip if no dependency changed
test.skip if deps[test].none? { |file| changed?(file) }
end
# Run test, otherwise
test.run
end
Since we are skipping tests, the coverage data will not include contributions from these tests. Note that we can store the coverage data for each test while computing the dependency and then add this to the coverage report.
tests.each do |test|
# Run test
test.run
# After test is run
test.after_run do
# Store the current coverage
curr_cov = Coverage.peek_result
# Find all the files with some coverage diff
deps[test] = changed_files(ref_cov, curr_cov)
# Store the coverage diff, i.e., contribution of this test only
cov[test] = cov_diff(ref_cov, curr_cov)
# Update the reference coverage for next test
ref_cov = curr_cov
end
end
# For all the tests we skipped
skipped_tests.each { |test| final_cov.sum(cov[test]) }
Sometimes we have flaky tests, and it is tough to find out such tests. No worries, RSpec Tracer will do it for you. It takes care of the scenarios when some previously failing tests pass in the current run without any dependency change and flag them as flaky. It would keep running these tests irrespective of the execution result unless you changed the dependent files.
We can use the commit objects to refer to a previous run cache files set. We
traverse the Git ancestry for each run to find the nearest commit SHA with the
proper cache files. Assuming BRANCH_REF
denotes the commit SHA to upload the
cache files, i.e., the most accurate commit SHA on the PR branch. It will always
be the HEAD
ref on the main branch.
The following command fetches the list of 25 commits starting from the branch ref:
$ git rev-list --max-count=25 $BRANCH_REF
If you use git commit --amend
, git pull -r origin main
, and git merge origin/main
,
etc., then the commit SHA will change, and the last run remote cache reference
is lost. To deal with this, we maintain the branch refs with the committer timestamp
for each branch.
For the case when we merge the main branch into the feature branch, nothing special
is required. But mostly, all the CI use the refs/pull/<number>/merge
reference
created by GitHub to track what would happen if you merged the pull request. It
references the merge commit between refs/pull/<number>/head
and the target branch.
Technically, it is a future commit, so it will not be part of the Git ancestry in
the subsequent runs. Therefore, we cannot annotate cache files using this commit SHA.
So, how to deal with such a case? We know that each merge commit has two immediate parents. You can use the following command to list both these:
$ git rev-parse HEAD^@
Note that in this case, the second parent, i.e., git rev-parse HEAD^2
is the
current branch HEAD, i.e., refs/pull/<number>/head
. We need is to ignore
all such commit SHA for referencing the cache files:
$ git rev-list $BRANCH_REF..origin/HEAD
When we configure CI to shallow clone the repository (using --depth
option),
the root commit is marked as grafted. It works by letting users record fake ancestry
information for commits. Because of this the ancestry refs will be all the
refs present in the build branch and the grafted one. Since RSpec Tracer tries
to maintain at most 25 ancestry refs, in the case of a shallow clone, the minimum
clone depth should be 25 to increase the chances of finding a cache in the ancestry
refs when there is no suitable ref in the branch refs.
As we have the list of commit SHA, we need to validate and find the best one for us, i.e., sometimes, a few commits from the list will not be available, and some might not have finished yet. For example, consider pulling changes from the main branch the moment it started running build and creating a PR. In this case, the PR should not use the main branch SHA because it has incomplete cache files pushed to S3.
RSpec Tracer generates nine files for each run, so if you run tests in different suites, say, 5, then the full cache has 45 objects. Therefore, we can first find such a commit and then download the files.
Fetched the following ancestry refs for feature branch:
* 470c7703836d96216fcc5853953b8ced3598517f (commit timestamp: 1631471386)
* 31165fc203b4cbc6cbeb440c343f121e6be09ee9 (commit timestamp: 1631436436)
* bc2eabc567dccbb3bb17a83585f77ae17e6ef031 (commit timestamp: 1631396595)
* 47763b1ec9f765ac801d658654c9dd6063094c1a (commit timestamp: 1631396481)
* d5da2df58decb554f2d869ea6c8e6a40ca89d47f (commit timestamp: 1631385874)
* f6ff6689f5ff551e2f533f4e765a9c20a76faae8 (commit timestamp: 1631383924)
* 805741242ad751e4a95ff667c3fb3637db54fe5e (commit timestamp: 1630605903)
Fetched the following branch refs for feature branch:
* 470c7703836d96216fcc5853953b8ced3598517f (commit timestamp: 1631471386)
* 63a313892338d60646856d5ea0dee63caf8043e2 (commit timestamp: 1631437893)
Fetched the following cache refs for feature branch:
* 470c7703836d96216fcc5853953b8ced3598517f (commit timestamp: 1631471386)
* 63a313892338d60646856d5ea0dee63caf8043e2 (commit timestamp: 1631437893)
* 31165fc203b4cbc6cbeb440c343f121e6be09ee9 (commit timestamp: 1631436436)
* bc2eabc567dccbb3bb17a83585f77ae17e6ef031 (commit timestamp: 1631396595)
* 47763b1ec9f765ac801d658654c9dd6063094c1a (commit timestamp: 1631396481)
* d5da2df58decb554f2d869ea6c8e6a40ca89d47f (commit timestamp: 1631385874)
* f6ff6689f5ff551e2f533f4e765a9c20a76faae8 (commit timestamp: 1631383924)
* 805741242ad751e4a95ff667c3fb3637db54fe5e (commit timestamp: 1630605903)